- Assuming SQL Server stores date/time values as formatted strings rather than binary
- Hard-coding date formats that don’t survive regional settings
- Not handling DATEFORMAT locale differences in string-to-date conversions
- Treating DATETIME2 as just DATETIME with extra precision
- Ignoring how SQL Server rounds DATETIME (to 3.33ms intervals)
- Removing the time portion of a date poorly (CAST to DATE vs DATEADD vs CONVERT)
- Misunderstanding DATEDIFF (which counts boundary crossings, not elapsed time)
- Writing search conditions that exclude edge-case rows (e.g., WHERE col = ‘2024-01-15’ missing any rows with a time component); (
- Forgetting year-range limits on specific types (DATETIME minimum is 1753)
- Not using SQL Server’s built-in date functions (DATEFROMPARTS, EOMONTH, ISDATE)
#1: Assuming SQL Server stores date/time values as formatted strings rather than binary
Many of the failures that surround handling date/time values often result from not understanding how SQL Server stores these values. (It doesn’t help that SQL Server documentation tends not to go too deeply into this topic.) Beginning T-SQL developers often assume that date/time values are stored as readable input, such as 05-07-2015 10:05:23.187. This is not the case. It would be more accurate to say that SQL Server stores the data as one or more integers, depending on the data type. Some sources suggest the data is stored as floats, but the concept is the same. We’re talking about numbers being stored, not formatted strings. Let’s start with theDATETIME data type. According to SQL Server documentation, the database engine stores a DATETIME value as two integers. The first integer represents the day and the second integer represents the time. The days can range from January 1, 1753, through December 31, 9999, and the times can range from 00:00:00.000 through 23:59:59.997, with the default value being 1900-01-01 00:00:00.000.
The default value is particularly important when it comes to the date. January 1, 1900 is considered day 0. Earlier dates are represented by negative integers and later dates by positive integers. For example, January 1, 1899 is day -365, and January 1, 1901 is day 365. As for the time portion, SQL Server starts with 0 and increments the numeric value for each .003 seconds after midnight. That means the time 00:00:00.003 is stored as 1, and the time 00:00:01.000 is stored as 300.
This can all seem confusing at first because when retrieving a DATETIME value because we see something much different. For example, let’s start with a simple DATETIME variable:
|
1 2 |
DECLARE @a DATETIME = '2015-05-07 10:05:23.187' SELECT @a; |
|
1 |
2015-05-07 10:05:23.187 |
VARBINARY type:
|
1 2 |
DECLARE @a DATETIME = '2015-05-07 10:05:23.187' SELECT CONVERT(VARBINARY(8), @a); |
|
1 |
0x0000A49100A6463C |
00A6463C) represent the time. Knowing this, we can use the SUBSTRING function to return only the date or time, as shown in the following example:
|
1 2 |
DECLARE @a DATETIME = '2015-05-07 10:05:23.187' SELECT SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4); |
SELECT statement returns only the bytes that represent the date integer:
|
1 |
0x0000A491 |
VARBINARY values to an INT values to see the actual number. Let’s put it all together to get an overview of how our original DATETIME value is stored:
|
1 2 3 4 5 6 |
DECLARE @a DATETIME = '2015-05-07 10:05:23.187' SELECT SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS DateBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS INT) AS DateInt, SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS TimeBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS INT) AS TimeInt; |
|
DateBinary |
DateInt |
TimeBinary |
TimeInt |
|
0x0000A491 |
42129 |
0x00A6463C |
10896956 |
|
1 2 3 4 5 6 |
DECLARE @a DATETIME = '1827-12-03 22:15:11.297' SELECT SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS DateBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS INT) AS DateInt, SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS TimeBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS INT) AS TimeInt; |
|
DateBinary |
DateInt |
TimeBinary |
TimeInt |
|
0xFFFF9929 |
-26327 |
0x016EB86D |
24033389 |
|
1 2 3 4 5 6 |
DECLARE @a DATETIME = '1900-01-01 00:00:00.000' SELECT SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS DateBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS INT) AS DateInt, SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS TimeBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS INT) AS TimeInt; |
VARBINARY and INT values all zero out:
|
DateBinary |
DateInt |
TimeBinary |
TimeInt |
|
0x00000000 |
0 |
0x00000000 |
0 |
|
1 2 3 4 5 6 |
DECLARE @a DATETIME = '1900-01-02 00:00:01.000' SELECT SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS DateBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 1, 4) AS INT) AS DateInt, SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS TimeBinary, CAST(SUBSTRING(CONVERT(VARBINARY(8), @a), 5, 4) AS INT) AS TimeInt; |
|
DateBinary |
DateInt |
TimeBinary |
TimeInt |
|
0x00000001 |
1 |
0x0000012C |
300 |
DATETIME values. However, SQL Server takes a somewhat different approach with other date/time data types. Let’s look at the DATETIME2 data type, configured at the default precision (7):
|
1 2 |
DECLARE @b DATETIME2 = '2015-05-07 10:05:23.1872436' SELECT CONVERT(VARBINARY(10), @b); |
|
1 |
0x07B4854E9254EC390B |
DATETIME2 data type, SQL Server uses the first byte to store the time precision (07), the last three bytes to store the date (EC390B), and everything in between to store the time (B4854E9254), which can vary in length depending upon the specified precision. The DATE and TIME data types work the same way when storing their portion of the value. For example, if we save the same value to a DATE variable:
|
1 2 |
DECLARE @b DATE = '2015-05-07 10:05:23.1872436' SELECT CONVERT(VARBINARY(10), @b); |
|
1 |
0xEC390B |
|
1 2 |
DECLARE @b TIME = '2015-05-07 10:05:23.1872436' SELECT CONVERT(VARBINARY(10), @b); |
DATETIME2 example:
|
1 |
0x07B4854E9254 |
DATETIME2 example to demonstrate the how the time portion can change:
|
1 2 |
DECLARE @b DATETIME2(4) = '2015-05-07 00:00:00.0001' SELECT CONVERT(VARBINARY(10), @b); |
SELECT statement returns the following results:
|
1 |
0x0401000000EC390B |
04) and that there are fewer time-related bytes (01000000). Unfortunately, the logic that SQL Server uses to store the date and time components of DATETIME2, DATE, and TIME is not as straightforward as with DATETIME, and digging into that logic is well beyond the scope of this article, but you can at least pick out the bytes that represent the date and time and have some sense of what is going on.
The point of all this is not to memorize the exact mechanisms SQL Server uses to store date/time data for each data type, but rather to have an overall sense of how the data is stored and that it is not simple strings that contain dates written in the prescribed formats. Having this foundation can help you better address the various issues that might arise when working with date/time values.
#2: Hard-coding date formats that don’t survive regional settings
T-SQL might ostensibly be accepted as a universal language, at least within certain database spheres, but SQL Server settings are not. Quite often, an installed instance is configured to best serve its local users. Where this often becomes particularly apparent is when handling date/time data. Although SQL Server stores the data as one or more integers, it seems to think in terms of string values, converting back and forth from integers to readable formats behind the scenes so we don’t have to deal with dates that look something like15481099 or times that like 24033389.
To this end, SQL Server includes a number of settings and rules that determine how to interpret values submitted as date/time data. Let’s start with a few examples. In the first one, we set the language to British and convert a VARCHAR value to DATETIME:
|
1 2 3 |
SET LANGUAGE british; DECLARE @a VARCHAR(20) = '19-05-2015 10:11:12'; SELECT CAST(@a AS DATETIME); |
SELECT statement returns the following results:
|
1 |
2015-05-19 10:11:12.000 |
|
1 2 3 |
SET LANGUAGE us_english; DECLARE @a VARCHAR(20) = '19-05-2015 10:11:12'; SELECT CAST(@a AS DATETIME); |
|
1 2 |
Msg 242, Level 16, State 3, Line 730 The conversion of a varchar data type to a datetime data type resulted in an out-of-range value. |
|
1 2 3 |
SET LANGUAGE us_english; DECLARE @a VARCHAR(20) = '2015-05-19 10:11:12'; SELECT CAST(@a AS DATETIME); |
SELECT statement will return the results just fine. If we were to change the language back to British, but submit the same value, we would again receive the out-of-range error.
Of course, we could reset the language once again, but this is not a particularly effective solution when trying to implement a global application. A better strategy is to ensure that our date/time values are more universal in nature, such as using a numeric format (year-month-day). For example, suppose we try to pass in the date/time value in a numeric format with hyphens:
|
1 2 3 |
SET LANGUAGE british; DECLARE @a VARCHAR(20) = '2015-05-19 10:11:12'; SELECT CAST(@a AS DATETIME); |
SELECT statement once again returns an out-of-range error:
|
1 2 |
Msg 242, Level 16, State 3, Line 204 The conversion of a varchar data type to a datetime data type resulted in an out-of-range value. |
|
1 2 3 |
SET LANGUAGE british; DECLARE @a VARCHAR(20) = '2015-05-19 10:11:12'; SELECT CAST(@a AS DATETIME2); |
SELECT statement now converts the date with no problem and returns the following results:
|
1 |
2015-05-19 10:11:12.0000000 |
DATETIME2 data type, we can avoid the language issue when using a numeric format, which is a fine option if all we’re running is SQL Server 2008 or later. But not everyone has this luxury. What we need is a format that is both type and language agnostic. For this reason, many developers default to a format such as ISO8601:
|
1 2 3 |
SET LANGUAGE british; DECLARE @a VARCHAR(20) = '2015-05-19T10:11:12'; SELECT CAST(@a AS DATETIME); |
#3: Not handling DATEFORMAT locale differences in string-to-date conversions
Our date formats are not the only place where we can get into trouble when implementing an application that spans multiple geographic locations. Time zones can also present their own challenges if we require a reliable means to track data over a period of time across multiple regions. One of the problems is that most SQL Server date/time data types are fairly ambiguous. For example, suppose we have a table in a database that tracks security-related events, and one of the rows shows an event occurring on May 15, 2015 at 3:30 in the morning. Is that the time on a local machine? The server’s time? Is SQL Server configured to use a time other than the local time? Is the value in Coordinated Universal Time (UTC)? Without some mechanism in place to provide context, the value is almost meaningless. In many cases, this might not be a problem, but for systems monitoring or recording critical processes, the exact time is essential. Imagine if an international bank has been hacked, with funds siphoned out of its accounts, and investigators having no way of knowing exactly when the event occurred. And what about time-sensitive analytics? Making sense of reams of historical data can become even more complex if it spans times zones with no mechanism in place to provide context to the data/time values, such as noting in what time zone the data was entered. Even if such mechanisms are put into place, it’s often up to the developers to implement solutions that take time zones into account, with no consistent approach from one application to the next (or sometimes within a single application). Fortunately, SQL Server 2008 introduced the DATETIMEOFFSET data type to make managing date/time data a little easier. The data type stores data/type values similarly to how aDATETIME2 value is stored, with a couple bytes tagged on to track the time zone, in relation to UTC. Consider the following example:
|
1 2 |
DECLARE @pdt DATETIMEOFFSET = SYSDATETIMEOFFSET(); SELECT @pdt; |
SYSDATETIMEOFFSET system function returns the current date and time as a DATETIMEOFFSET value, which means it includes the date, time, and UTC offset value, as the following results show:
|
1 |
2015-05-08 17:57:23.5350000 -07:00 |
UTC, putting us on the US West Coast. If we want, we can instead use the DATENAME system function to retrieve only the offset value:
|
1 2 |
DECLARE @pdt DATETIMEOFFSET = SYSDATETIMEOFFSET(); SELECT DATENAME(tzoffset, @pdt); |
SELECT statement returns only the difference from UTC:
|
1 |
-07:00 |
|
1 2 3 |
DECLARE @utc DATETIMEOFFSET = SYSUTCDATETIME(), @pdt DATETIMEOFFSET = SYSDATETIMEOFFSET(); SELECT @utc AS UTC, @pdt AS PDT; |
|
UTC |
PDT |
|
2015-05-09 00:57:37.1820000 +00:00 |
2015-05-08 17:57:37.1820000 -07:00 |
SWITCHOFFSET system function to change a DATETIMEOFFSET value to a different time zone:
|
1 2 |
DECLARE @a DATETIMEOFFSET = '2015-05-08 17:57:53.3390000 -07:00'; SELECT SWITCHOFFSET(@a, '-05:00'); |
|
1 |
2015-05-08 19:57:53.3390000 -05:00 |
|
1 2 3 |
DECLARE @mel DATETIMEOFFSET = '2015-04-01 17:57:53.3390000 +11:00', @sea DATETIMEOFFSET = '2015-04-01 17:57:53.3390000 -07:00'; SELECT DATEDIFF(hh, @mel, @sea); |
|
1 2 3 |
DECLARE @mel DATETIMEOFFSET = '2015-05-01 17:57:53.3390000 +11:00', @sea DATETIMEOFFSET = '2015-05-01 17:57:53.3390000 -07:00'; SELECT DATEDIFF(hh, @mel, @sea); |
#4: Treating DATETIME2 as just DATETIME with extra precision
AlthoughDATETIME2 has been around since SQL Server 2008, many developers have been slow to embrace it, sticking with DATETIME more out of habit than anything else. But DATETIME2 offers a number of advantages over DATETIME, in addition to the larger decimal values.
Let’s start by looking at the two in action:
|
1 2 3 |
DECLARE @a DATETIME2 = '2015-05-12 09:47:12.5556789', @b DATETIME = '2015-05-12 09:47:12.555'; SELECT @a AS DateTime2Type, @b AS DateTimeType; |
DATETIME2 data type supports up to seven decimal places for its time component, whereas DATETIME supports only three, which gives us the following results.
|
DateTime2Type |
DateTimeType |
|
2015-05-12 09:47:12.4556789 |
2015-05-12 09:47:12.457 |
DATETIME value to the nearest .003 seconds, with values rounded to increments of .000, .003. or .007 seconds. The DATETIME2 data type is much more precise in the regard. Although a value will be rounded if it exceeds the seven decimal places, no rounding occurs if the value is within the limit. For example, .555678999 is rounded to .5556790, but a value such as .9999999 is not rounded at all.
So in this regard, DATETIME2 is also more precise than DATETIME. In addition, you can control the DATETIME2 precision, also unlike DATETIME. For example, the following T-SQL limits the time portion of the DATETIME2 value to a precision of 3:
|
1 2 3 |
DECLARE @a DATETIME2(3) = '2015-05-12 09:47:12.5556789', @b DATETIME = '2015-05-12 09:47:12.555'; SELECT @a AS DateTime2Type, @b AS DateTimeType; |
DATETIME value.
|
DateTime2Type |
DateTimeType |
|
2015-05-12 09:47:12.556 |
2015-05-12 09:47:12.557 |
DATETIME2 time portion is rounded because the submitted value exceeded the specified precision, but even this rounding is more precise than what we get with DATETIME. And here’s another interesting part to all this. Although both values take three decimal places, SQL Server uses only 7 bytes to store the DATETIME2 value, but 8 bytes to store the DATETIME value.
In fact, a DATETIME2 value uses 8 bytes only if the precision is greater than 4, and uses only 6 bytes if the precision is less than 3. Not only do you can more precision with DATETIME2, but you can also save storage space, which can be a particularly important consideration when bringing lots of data into memory.
The DATETIME2 data type also has the advantage of being able to remove the decimal places altogether:
|
1 2 3 |
DECLARE @a DATETIME2(0) = '2015-05-12 09:47:12.5556789', @b DATETIME = '2015-05-12 09:47:12.555'; SELECT @a AS DateTime2Type, @b AS DateTimeType; |
|
DateTime2Type |
DateTimeType |
|
2015-05-12 09:47:13 |
2015-05-12 09:47:12.557 |
DATETIME2 data type goes all the way back to January 1, 0001, whereas the DATETIME data type goes back only to January 1, 1753. (They both go to the year 9999, which I’m sure is a comfort to many.)
Also worth noting about the DATETIME2 data type is that we also get the DATE and TIME data types, which offer the same level of precision
|
1 2 3 |
DECLARE @a DATE = '2015-05-12 09:47:12.5556789', @b TIME = '2015-05-12 09:47:12.5556789'; SELECT @a AS DateType, @b AS TimeType; |
|
DateType |
TimeType |
|
2015-05-12 |
09:47:12.5556789 |
DATETIME2 values. The DATETIME2 data type and other new types offer too many advantages to ignore, including being better aligned with the .NET date/time types. And as pointed out earlier, DATETIME2 is also more forgiving when it comes to the data/time formats you pass into the database. Clearly, it’s time to break old habits and let the DATETIME data type go.
#5: Ignoring how SQL Server rounds DATETIME (to 3.33ms intervals)
In the previous failure, we touched upon rounding, but it is a topic that deserves more than just a brief mention, especially as it concerns theDATETIME and SMALLDATETIME data types. But first, let’s look at what happens when we round DATETIME2 data:
|
1 2 3 4 5 6 7 8 |
DECLARE @a DATETIME2 = '2015-05-12 23:32:12.1234567', @b DATETIME2 = '2015-05-12 23:32:12.123456789', @c DATETIME2 = '2015-05-12 23:59:59.999999999'; SELECT '2015-05-12 23:32:12.1234567' AS OrigValue, @a AS StoredValue UNION ALL SELECT '2015-05-12 23:32:12.123456789', @b UNION ALL SELECT '2015-05-12 23:59:59.999999999', @c; |
DATETIME2 precision is the default 7, so that is the number of decimal places for each value. As the following results show, the @a value does not round the data in any way, but the @b and @c values do:
|
OrigValue |
StoredValue |
|
2015-05-12 23:32:12.1234567 |
2015-05-12 23:32:12.1234567 |
|
2015-05-12 23:32:12.123456789 |
2015-05-12 23:32:12.1234568 |
|
2015-05-12 23:59:59.999999999 |
2015-05-13 00:00:00.0000000 |
@b value is rounded as we would expect. Nine digits are pared down to seven digits, with the value 123456789 rounded up to 1234568. The @c value also follows similar logic. However, because we normally round up under these circumstances, we move onto the next day. In both cases, SQL Server is following very predictable logic. Although the possibility exists for a value to get bumped up to the next day, it still follows along expected lines.
Now lets look what happens with a few DATETIME values:
|
1 2 3 4 5 6 7 8 |
DECLARE @a DATETIME = '2015-05-12 23:59:59.996', @b DATETIME = '2015-05-12 23:59:59.998', @c DATETIME = '2015-05-12 23:59:59.999'; SELECT '2015-05-12 23:59:59.996' AS OrigValue, @a AS DatetimeValue UNION ALL SELECT '2015-05-12 23:59:59.998', @b UNION ALL SELECT '2015-05-12 23:59:59.999', @c; |
@a value gets rounded up, the @b value gets rounded down, and the @c value flies over to the next day, as shown in the following results:
|
OrigValue |
DatetimeValue |
|
2015-05-12 23:59:59.996 |
2015-05-12 23:59:59.997 |
|
2015-05-12 23:59:59.998 |
2015-05-12 23:59:59.997 |
|
2015-05-12 23:59:59.999 |
2015-05-13 00:00:00.000 |
|
1 2 3 |
DECLARE @a DATETIME2 = '2015-05-12 23:59:59.9986789'; DECLARE @b DATETIME = @a; SELECT @a AS Datetime2Value, @b AS DatetimeValue; |
|
Datetime2Value |
DatetimeValue |
|
2015-05-12 23:59:59.9986789 |
2015-05-13 00:00:00.000 |
|
1 2 3 4 5 6 7 8 9 10 11 |
DECLARE @a SMALLDATETIME = '2015-05-12 23:22:22', @b SMALLDATETIME = '2015-05-12 23:22:30', @c SMALLDATETIME = '2015-05-12 23:22:52', @d SMALLDATETIME = '2015-05-12 23:59:52'; SELECT '2015-05-12 23:22:22' AS OrigValue, @a AS SmalldatetimeValue UNION ALL SELECT '2015-05-12 23:22:30', @b UNION ALL SELECT '2015-05-12 23:22:52', @c UNION ALL SELECT '2015-05-12 23:59:52', @d; |
SMALLDATETIME is accurate only to the nearest minute, despite the fact that the returned values always show 00 seconds:
|
OrigValue |
SmalldatetimeValue |
|
2015-05-12 23:22:22 |
2015-05-12 23:22:00 |
|
2015-05-12 23:22:30 |
2015-05-12 23:23:00 |
|
2015-05-12 23:22:52 |
2015-05-12 23:23:00 |
|
2015-05-12 23:59:52 |
2015-05-13 00:00:00 |
@a value is fairly straightforward. The 22 seconds are rounded down, so the minute value remains unchanged. The @b value is rounded up because SQL Server rounds 30 seconds or more up to the next minute, which is also the case for the @c value. The @d value, however, flips over to the next day because the 59 minute also get rounded up, causing the 23 hours to get rounded up.
Now look at what happens if we add fractional seconds to the mix:
|
1 2 |
DECLARE @a SMALLDATETIME = '2015-05-12 23:59:29.999'; SELECT @a; |
|
1 |
2015-05-13 00:00:00 |
DATETIME2 work, that’s the way to go.
#6: Removing the time portion of a date poorly (CAST to DATE vs DATEADD vs CONVERT)
Often you’ll find that you’re not interested in the time portion of a date/time value and want to zero out the time or get rid of it altogether. Prior to SQL Server 2008, you had to work a little harder to get at the date, but now we have theDATE data type to make our lives easier:
|
1 2 |
DECLARE @a DATETIME2(3) = '2015-05-12 22:14:18.003'; SELECT CAST(@a AS DATE); |
|
1 |
2015-05-12 |
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT CAST(@a AS DATE); |
TIME data type:
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT CAST(@a AS TIME(3)); |
SELECT statement now returns only the time:
|
1 |
22:14:18.003 |
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT CAST(CONVERT(CHAR(8), @a, 112) AS DATETIME); |
DATETIME value, giving us the following results:
|
1 |
2015-05-12 00:00:00.000 |
DATEADD and DATEDIFF system functions to zero out those dates:
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT DATEADD(DAY, DATEDIFF(DAY, 0, @a), 0); |
|
1 |
2015-05-12 00:00:00.000 |
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT DATEADD(DAY, DATEDIFF(DAY, '20010101', @a), '20010101'); |
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT DATEADD(MONTH, DATEDIFF(MONTH, '20010101', @a), '20010101'); |
|
1 |
2015-05-01 00:00:00.000 |
|
1 2 |
DECLARE @a DATETIME = '2015-05-12 22:14:18.003'; SELECT DATEADD(MONTH, DATEDIFF(MONTH, '20010101', @a), '20001231'); |
SELECT statement returns the following results:
|
1 |
2015-04-30 00:00:00.000 |
DATE and TIME data types available to you, these last two examples can be handy approaches to keep in mind for retrieving the data you need.
#7: Misunderstanding DATEDIFF (which counts boundary crossings, not elapsed time)
While we’re on the subject of DATEDIFF, it’s worth taking a closer look. If we’re not careful when using the function, we can end up with results not quite as accurate as we might hope. Suppose we are trying to determine the number of minutes between two date/time values:|
1 2 3 |
DECLARE @a DATETIME2(3) = '2015-12-31 23:59:59.000', @b DATETIME2(3) = '2016-01-01 00:00:00.000'; SELECT DATEDIFF(mi, @a, @b); |
SELECT statement returns a value of 1, a full minute, even though there is only a one-second difference between these two dates. The same thing happens if we try to find the hour difference between the values:
|
1 2 3 |
DECLARE @a DATETIME2(3) = '2015-12-31 23:59:59.000', @b DATETIME2(3) = '2016-01-01 00:00:00.000'; SELECT DATEDIFF(hh, @a, @b); |
SELECT statement indicates that there is a one-hour difference between the values, rather than one second. Same thing happens with months:
|
1 2 3 |
DECLARE @a DATETIME2(3) = '2015-12-31 23:59:59.000', @b DATETIME2(3) = '2016-01-01 00:00:00.000'; SELECT DATEDIFF(mm, @a, @b); |
|
1 2 3 |
DECLARE @a DATETIME2(3) = '2015-12-31 23:59:59.000', @b DATETIME2(3) = '2016-01-01 00:00:00.000'; SELECT DATEDIFF(ss, @a, @b)/60.0000; |
SELECT statement returns 0.0166666, rather than 1, which is much closer to the truth.
#8: Writing search conditions that exclude edge-case rows (e.g., WHERE col = ‘2024-01-15’ missing any rows with a time component); (
Referencing date/time values in your search conditions can be tricky business if not done carefully. Without exercising due caution, you can end up retrieving the wrong data or, worse still, updating and deleting the wrong data. To demonstrate why this might be an issue, let’s first create a temporary table and populate it with several rows that include DATETIME2 data:|
1 2 3 4 5 6 7 8 |
CREATE TABLE #a (ColA INT, ColB DATETIME2(3)); INSERT INTO #a VALUES (101, '2015-05-06 22:43:55.123'), (102, '2015-05-06 23:59:59.997'), (103, '2015-05-07 00:00:00.000'), (104, '2015-05-07 17:33:36.321'), (105, '2015-05-08 00:00:00.000'), (106, '2015-05-08 10:18:12.987'); |
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB = '2015-05-07'; |
|
ColA |
ColB |
|
103 |
2015-05-07 00:00:00.000 |
DATETIME2 data type, like other date/time data types, store both the date and time, with the time often being something other than midnight (all zeroes). However, when we compare a date-only value to a value in a date/time column, SQL Server uses midnight for the date-only value. As a result, a comparison such as the one in the WHERE clause above will filter out all rows except those with a matching date and with midnight for the time.
One way to get around this is to convert the column data to match the date-only value:
|
1 2 |
SELECT ColA, ColB FROM #a WHERE CONVERT(CHAR(8), ColB, 112) = '20150507'; |
SELECT statement returns the results we want:
|
ColA |
ColB |
|
103 |
2015-05-07 00:00:00.000 |
|
104 |
2015-05-07 17:33:36.321 |
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB BETWEEN '2015-05-06' AND '2015-05-08'; |
|
ColA |
ColB |
|
101 |
2015-05-06 22:43:55.123 |
|
102 |
2015-05-06 23:59:59.997 |
|
103 |
2015-05-07 00:00:00.000 |
|
104 |
2015-05-07 17:33:36.321 |
|
105 |
2015-05-08 00:00:00.000 |
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB BETWEEN '2015-05-07' AND '2015-05-07'; |
SELECT statement is back to returning only one row:
|
ColA |
ColB |
|
103 |
2015-05-07 00:00:00.000 |
'2015-05-07'. The BETWEEN operator is basing its calculations on the entire value, including the time, so our WHERE clause essentially looks like the following:
|
1 |
WHERE ColB BETWEEN '2015-05-07 00:00:00.000' AND '2015-05-07 00:00:00.000'; |
Of course, this will return only the same one row. However, we can address this issue by being more precise with our end date:
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB BETWEEN '2015-05-07' AND '2015-05-07 23:59:59:999'; |
SELECT statement returns the results we want:
|
ColA |
ColB |
|
103 |
2015-05-07 00:00:00.000 |
|
104 |
2015-05-07 17:33:36.321 |
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB >= '2015-05-07' AND colB < '2015-05-08'; |
SELECT statement once again returns the results we want, while keeping our query simple. This approach is also the best way to go when working with the DATETIME data type. Let me demonstrate. Suppose we had originally defined ColB with the DATETIME data type and populated the table accordingly:
|
1 2 3 4 5 6 7 8 |
CREATE TABLE #a (ColA INT, ColB DATETIME); INSERT INTO #a VALUES (101, '2015-05-06 22:43:55.123'), (102, '2015-05-06 23:59:59.997'), (103, '2015-05-07 00:00:00.000'), (104, '2015-05-07 17:33:36.321'), (105, '2015-05-08 00:00:00.000'), (106, '2015-05-08 10:18:12.987'); |
BETWEEN operator with our precise data range to retrieve the data:
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB BETWEEN '2015-05-07' AND '2015-05-07 23:59:59:999'; |
SELECT statement returns three rows:
|
ColA |
ColB |
|
103 |
2015-05-07 00:00:00.000 |
|
104 |
2015-05-07 17:33:36.320 |
|
105 |
2015-05-08 00:00:00.000 |
DATATIME limitations. This causes our end date to flip over to the next day, resulting in the first row for that day being included in the result set. To get around this, we can again turn to the equality operators:
|
1 2 |
SELECT ColA, ColB FROM #a WHERE ColB >= '2015-05-07' AND colB < '2015-05-08'; |
SELECT statement returns the expected two rows:
|
ColA |
ColB |
|
103 |
2015-05-07 00:00:00.000 |
|
104 |
2015-05-07 17:33:36.320 |
#9: Forgetting year-range limits on specific types (DATETIME minimum is 1753)
This might seem like a no-brainer, but it’s worth a reminder. If you’re converting data from one date/time type to another, you must keep the permitted date ranges in mind. Case in point:|
1 2 3 4 |
DECLARE @a DATETIME2 = '1623-01-01', @b DATETIME = NULL; SET @b = @a; SELECT @b; |
DATETIME2 value for the year 1623 to a DATETIME value. Unfortunately, the DATETIME data type supports only the years 1753 through 9999. Although this is a good thing for those with an unwavering hope in the future, it’s not such good news for those with a penchant for historical trivia or who want to run queries similar to the one shown above, which lead to results similar to the following:
|
1 2 |
Msg 242, Level 16, State 3, Line 792 The conversion of a datetime2 data type to a datetime data type resulted in an out-of-range value. |
SMALLDATETIME data type is even more limited:
|
1 2 3 4 |
DECLARE @a DATETIME = '1823-01-01', @b SMALLDATETIME = NULL; SET @b = @a; SELECT @b; |
SELECT statement will once again return an out-of-range error because the SMALLDATETIME data type supports only the years 1900 through 2079. When converting data from one date/time type to another, be sure to keep these limitations in mind.
#10: Not using SQL Server’s built-in date functions (DATEFROMPARTS, EOMONTH, ISDATE)
SQL Server 2008 added great built-in functions for working with date/time data, and it would be a shame not to take full advantage of them. For some, however, that will mean imagining a world beyond GETDATE or GETUTCDATE. Let’s look at some of the date/time functions in action:|
1 2 3 4 5 |
SELECT GETDATE(); SELECT SYSDATETIME(); SELECT GETUTCDATE(); SELECT SYSUTCDATETIME(); SELECT SYSDATETIMEOFFSET(); |
|
1 2 3 4 5 |
2015-05-14 10:28:21.700 2015-05-14 10:28:21.7030000 2015-05-14 17:28:21.700 2015-05-14 17:28:21.7030000 2015-05-14 10:28:21.7030000 -07:00 |
SYSDATETIME function returns the current date and time as a DATETIME2 value. The SYSUTCDATETIME function returns the same data, but as a UTC value. The SYSDATETIMEOFFSET function returns the current date and time as a DATETIMEOFFSET value, which means we also get the offset amount.
SQL Server 2008 also included enhancements to the DATENAME and DATEPART functions to provide support for the newer date/time data types. The functions now include date part options for microseconds, nanoseconds, and UTC offsets. The following example shows the new date parts used in the DATENAME function:
|
1 2 3 4 |
DECLARE @a DATETIMEOFFSET = '2015-05-12 19:14:38.9046722 -07:00'; SELECT DATENAME(mcs, @a) AS Microseconds, DATENAME(ns, @a) AS Nanoseconds, DATENAME(tz, @a) AS TimezoneOffset; |
|
Microseconds |
Nanoseconds |
TimezoneOffset |
|
904672 |
904672200 |
-07:00 |
DATEPART function works pretty much the same way:
|
1 2 3 4 |
DECLARE @a DATETIMEOFFSET = '2015-05-12 19:14:38.9046722 -07:00'; SELECT DATEPART(mcs, @a) AS Microseconds, DATEPART(ns, @a) AS Nanoseconds, DATEPART(tz, @a) AS TimezoneOffset; |
|
Microseconds |
Nanoseconds |
TimezoneOffset |
|
904672 |
904672200 |
-420 |
The series so far:
- SQL Server Graph Databases - Part 1: Introduction
- SQL Server Graph Databases - Part 2: Querying Data in a Graph Database
- SQL Server Graph Databases - Part 3: Modifying Data in a Graph Database
- SQL Server Graph Databases - Part 4: Working with hierarchical data in a graph database
- SQL Server Graph Databases - Part 5: Importing Relational Data into a Graph Database
FAQs
1. What is the difference between DATETIME and DATETIME2 in SQL Server?
DATETIME stores dates from 1753-01-01 to 9999-12-31 with 3.33-millisecond precision using 8 bytes. DATETIME2 stores dates from 0001-01-01 to 9999-12-31 with up to 100-nanosecond precision using 6-8 bytes depending on the specified scale. DATETIME2 is the modern, preferred type for new development: wider date range, better precision, ANSI-compliant, same or less storage. The DATETIME type is retained only for backward compatibility. Do not treat DATETIME2 as simply ‘DATETIME with extra precision’ – the rounding behaviour differs and the storage format is distinct.
2. Why does my SQL Server DATEDIFF return unexpected results?
DATEDIFF counts the number of boundary crossings between two dates at the specified interval, not the elapsed time. DATEDIFF(DAY, ‘2024-01-01 23:59:59’, ‘2024-01-02 00:00:01’) returns 1 even though the elapsed time is 2 seconds – there’s one midnight boundary between the two values. Similarly, DATEDIFF(YEAR, ‘2023-12-31’, ‘2024-01-01’) returns 1 even though the elapsed time is 1 day. For elapsed-time calculations, use DATEDIFF with smaller intervals (seconds or milliseconds) and then divide, or use DATEADD + comparison logic.
3. How do I remove the time from a date in SQL Server?
The cleanest approach is CAST(your_column AS DATE), which returns just the date portion. This works from SQL Server 2008 onwards when the DATE type was introduced. Legacy alternatives include CONVERT(DATE, your_column) (identical to CAST in effect), CONVERT(CHAR(8), your_column, 112) + CAST back (slower), or DATEADD(DAY, DATEDIFF(DAY, 0, your_column), 0) (truncates time while keeping DATETIME type). Avoid string-manipulation approaches (CONVERT(VARCHAR, column, 101)) – they produce locale-dependent output and can fail in search conditions. For new code on SQL Server 2008+, CAST to DATE is the canonical answer.
4. Why does my WHERE clause on a DATETIME column miss some rows in SQL Server?
A common trap: WHERE OrderDate = ‘2024-01-15’ compares DATETIME values, and a DATETIME with any time component (for example, ‘2024-01-15 14:30:00’) will not equal ‘2024-01-15 00:00:00’. The query silently misses all rows with a non-midnight time. The correct pattern is a range check: WHERE OrderDate >= ‘2024-01-15’ AND OrderDate < ‘2024-01-16’. This captures all rows on the target date regardless of time component and remains SARGable (allows index seeks). Never wrap the column in CAST/CONVERT/DATEADD in a WHERE clause – that disables the index.
This document contains proprietary information and is protected by copyright law.
Copyright © 2026 Red Gate Software Limited. All rights reserved
Load comments